#!/usr/bin/python
#
# Copyright 2013 Greg Neagle
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""autopkg tool. Runs autopkg recipes and also handles other
related tasks"""

import sys

if 'darwin' != sys.platform:
    print "----------------------------------------------------------------------------------"
    print "--  WARNING: AutoPkg is not completely functional on platforms other than OS X  --"
    print "----------------------------------------------------------------------------------"

import difflib
import glob
import os
import operator
import optparse

try:
    import FoundationPlist
except:
    print 'WARNING: importing plistlib as FoundationPlist'
    import plistlib as FoundationPlist

import pprint
import re
import shutil
import subprocess
import time
import traceback
import autopkglib.github

from urlparse import urlparse

from autopkglib import get_pref, get_all_prefs
from autopkglib import set_pref, PreferenceError
from autopkglib import AutoPackagerError, AutoPackager
from autopkglib import get_processor, processor_names
from autopkglib import get_autopkg_version, version_equal_or_greater
from autopkglib import find_recipe_by_identifier, \
                       get_identifier


def log(msg, error=False):
    '''Message logger, prints to stdout/stderr.'''
    if error:
        print >> sys.stderr, msg
    else:
        print >> sys.stdout, msg


def log_err(msg):
    '''Message logger for errors.'''
    log(msg, error=True)


def print_version(argv):
    '''Prints autopkg version'''
    # make PyLint happy here since all our verb functions are passed argv
    _ = argv[1]
    print get_autopkg_version()


def recipe_has_step_processor(recipe, processor):
    '''Does the recipe object contain at least one step with the
    named Processor?'''
    if "Process" in recipe:
        processors = [step.get("Processor") for step in recipe["Process"]]
        if processor in processors:
            return True
    return False


def has_munkiimporter_step(recipe):
    '''Does the recipe have a MunkiImporter step?'''
    return recipe_has_step_processor(recipe, "MunkiImporter")


def has_check_phase(recipe):
    '''Does the recipe have a "check" phase?'''
    return recipe_has_step_processor(recipe, "EndOfCheckPhase")


def builds_a_package(recipe):
    '''Does this recipe build any packages?'''
    return recipe_has_step_processor(recipe, "PkgCreator")


def recipe_plist_from_file(filename):
    '''Create a recipe plist from a file. Handle exceptions and log'''
    if os.path.isfile(filename):
        try:
            # make sure we can read it
            recipe_plist = FoundationPlist.readPlist(filename)
        except FoundationPlist.FoundationPlistException, err:
            log_err(
                "WARNING: plist error for %s: %s" % (filename, unicode(err)))
            return
        return recipe_plist


def valid_recipe_plist_with_keys(recipe_plist, keys_to_verify):
    '''Attempts to read a plist and ensures the keys in
    keys_to_verify exist. Returns False on any failure, True otherwise.'''
    if recipe_plist:
        for key in keys_to_verify:
            if not key in recipe_plist:
                return False
        # if we get here, we found all the keys
        return True
    return False


def valid_plist_with_keys(filename, keys_to_verify):
    '''Attempts to read a plist file and ensures the keys in
    keys_to_verify exist. Returns False on any failure, True otherwise.'''
    recipe_plist = recipe_plist_from_file(filename)
    return valid_plist_with_keys(recipe_plist, keys_to_verify)


def valid_recipe_plist(recipe_plist):
    '''Returns True if recipe plist is a valid recipe,
    otherwise returns False'''
    return (
        valid_recipe_plist_with_keys(recipe_plist, ["Input", "Process"])
        or valid_recipe_plist_with_keys(recipe_plist, ["Input", "Recipe"])
        or valid_recipe_plist_with_keys(recipe_plist, ["Input", "ParentRecipe"])
    )


def valid_recipe_file(filename):
    '''Returns True if filename contains a valid recipe,
    otherwise returns False'''
    recipe_plist = recipe_plist_from_file(filename)
    return valid_recipe_plist(recipe_plist)


def valid_override_plist(recipe_plist):
    '''Returns True if the recipe is a valid override,
    otherwise returns False'''
    return (
        valid_recipe_plist_with_keys(recipe_plist, ["Input", "ParentRecipe"])
        or valid_recipe_plist_with_keys(recipe_plist, ["Input", "Recipe"]))


def valid_override_file(filename):
    '''Returns True if filename contains a valid override,
    otherwise returns False'''
    override_plist = recipe_plist_from_file(filename)
    return valid_override_plist(override_plist)


def find_recipe_by_name(name, search_dirs):
    '''Search search_dirs for a recipe by file/directory naming rules'''
    if name.endswith(".recipe"):
        # drop ".recipe" from the end of the name because we're
        # going to add it back on...
        name = os.path.splitext(name)[0]
    # search by "Name", using file/directory hierarchy rules
    for directory in search_dirs:
        normalized_dir = os.path.abspath(os.path.expanduser(directory))
        patterns = [
            os.path.join(normalized_dir, "%s.recipe" % name),
            os.path.join(normalized_dir, "*/%s.recipe" % name)
        ]
        for pattern in patterns:
            matches = glob.glob(pattern)
            for match in matches:
                if valid_recipe_file(match):
                    return match

    return None


def find_recipe(id_or_name, search_dirs):
    '''find a recipe based on a string that might be an identifier
    or a name'''
    return (find_recipe_by_identifier(id_or_name, search_dirs) or
            find_recipe_by_name(id_or_name, search_dirs))


def get_identifier_from_override(override):
    '''Return the identifier from an override, falling back with a
    warning to just the 'name' of the recipe.'''
    # prefer ParentRecipe
    identifier = override.get('ParentRecipe')
    if identifier:
        return identifier
    identifier = override['Recipe'].get("identifier")
    if identifier:
        return identifier
    else:
        name = override['Recipe'].get("name")
        log_err("WARNING: Override contains no identifier. Will fall "
                "back to matching it by name using search rules. It's "
                "recommended to give the original recipe identifier "
                "in the override's 'Recipes' dict to ensure the same "
                "recipe is always used for this override.")
    return name


def locate_recipe(name, override_dirs, recipe_dirs,
                  make_suggestions=True, search_github=True):
    '''Locates a recipe by name. If the name is the pathname to a file on disk,
    we attempt to load that file and use it as recipe. If a parent recipe
    is required we first add the child recipe's directory to the search path
    so that the parent can be found, assuming it is in the same directory.

    Otherwise, we treat name as a recipe name or identifier and search first
    the override directories, then the recipe directories for a matching
    recipe.'''

    recipe_file = None
    if os.path.isfile(name):
        # name is path to a specific recipe or override file
        # ignore override and recipe directories
        # and attempt to open the file specified by name
        if valid_recipe_file(name):
            recipe_file = name

    if not recipe_file:
        # name wasn't a filename. Let's search our local repos.
        recipe_file = find_recipe(name, override_dirs + recipe_dirs)

    if not recipe_file and make_suggestions:
        print "Didn't find a recipe for %s." % name
        make_suggestions_for(name)

    if not recipe_file and search_github:
        indef_article = "a"
        if name[0] in ["a", "e", "i", "o", "u"]:
            indef_article = "an"
        answer = raw_input(
            "Search GitHub AutoPkg repos for %s %s recipe? [y/n]: "
            % (indef_article, name))
        if answer.lower().startswith('y'):
            results_limit = 100
            query = "q=%s+extension:recipe+user:autopkg+in:file,path" % name
            query += "&per_page=%s" % results_limit

            results = do_gh_code_search(query, use_token=False)

            if not results or not results.get('total_count'):
                print "Nothing found."
                return None

            results_items = results["items"]

            # Note 2015-03-11: this next block would erroneously remove repos
            # that have new recipes that match our search (new recipes we don't
            # yet have locally because we haven't done a repo-update)
            #
            ## filter out any results that are in repos we already have added
            ## locally
            #recipe_repo_urls = []
            #recipe_repos = get_pref("RECIPE_REPOS") or {}
            #for key in recipe_repos.keys():
            #    recipe_repo_urls.append(recipe_repos[key]['URL'])
            #results_items = [
            #    item for item in results_items
            #    if item['repository']['html_url'] not in recipe_repo_urls]
            #if len(results_items) > 1:
            #    # filter results items by matching filenames.
            #    # this works around the fact that we can't do a
            #    # case-insensitive filename search with the GitHub API
            #    results_items = [item for item in results_items
            #                     if name.lower() in item['name'].lower()]

            if not results_items:
                print "Nothing found."
                return None

            print_gh_search_results(results_items)

            # make a list of unique repo names
            repo_names = []
            for item in results_items:
                repo_name = item["repository"]['name']
                if repo_name not in repo_names:
                    repo_names.append(repo_name)

            if len(repo_names) == 1:
                # we found results in a single repo, so offer to add it
                repo = results_items[0]["repository"]
                print
                answer = raw_input(
                    "Add recipe repo '%s'? [y/n]: " % repo['name'])
                if answer.lower().startswith('y'):
                    repo_add([None, 'repo-add', repo['name']])
                    # try once again to locate the recipe, but don't
                    # search GitHub again!
                    print
                    recipe_dirs = get_search_dirs()
                    recipe_file = locate_recipe(
                        name, override_dirs, recipe_dirs,
                        make_suggestions=True, search_github=False)
            elif len(repo_names) > 1:
                print
                print ("To add a new recipe repo, use 'autopkg repo-add "
                       "<repo name>'")
                return None

    return recipe_file


def load_recipe(name, override_dirs, recipe_dirs,
                preprocessors=None, postprocessors=None,
                make_suggestions=True, search_github=True):
    '''Loads a recipe, first locating it by name.
    If we find one, we load it and return the plist object (which
    should be functionally equivelent to a dictionary). If an override file is
    used, it prefers finding the original recipe by identifier rather than
    name, so that if recipe names shift with updated recipe repos, the
    override still applies to the recipe from which it was derived.'''

    recipe = None
    recipe_file = locate_recipe(name, override_dirs, recipe_dirs,
                                make_suggestions=make_suggestions,
                                search_github=search_github)

    if recipe_file:
        # read it
        recipe = FoundationPlist.readPlist(recipe_file)
        # does it refer to another recipe?
        if recipe.get("ParentRecipe") or recipe.get("Recipe"):
            # save current recipe as a child
            child_recipe = recipe
            parent_id = get_identifier_from_override(recipe)
            # add the recipe's directory to the search path
            # so that we'll be able to locate the parent
            recipe_dirs.append(os.path.dirname(recipe_file))
            # load its parent, this time not looking in override directories
            recipe = load_recipe(parent_id, [], recipe_dirs,
                                 make_suggestions=make_suggestions,
                                 search_github=search_github)
            if recipe:
                # merge child_recipe
                recipe["Identifier"] = get_identifier(child_recipe)
                recipe["Description"] = child_recipe.get(
                    "Description", recipe.get("Description", ""))
                for key in child_recipe["Input"].keys():
                    recipe["Input"][key] = child_recipe["Input"][key]

                # take the highest of the two MinimumVersion keys, if they exist
                for candidate_recipe in [recipe, child_recipe]:
                    if 'MinimumVersion' not in candidate_recipe.keys():
                        candidate_recipe['MinimumVersion'] = '0'
                if version_equal_or_greater(child_recipe['MinimumVersion'],
                                            recipe['MinimumVersion']):
                    recipe['MinimumVersion'] = child_recipe['MinimumVersion']

                recipe["Process"].extend(child_recipe.get("Process", []))
                if recipe.get("RECIPE_PATH"):
                    if not "PARENT_RECIPES" in recipe:
                        recipe["PARENT_RECIPES"] = []
                    recipe["PARENT_RECIPES"] = (
                        [recipe["RECIPE_PATH"]] + recipe["PARENT_RECIPES"])
                recipe["RECIPE_PATH"] = recipe_file
            else:
                # no parent recipe, so the current recipe is invalid
                log_err('Could not find parent recipe for %s' % name)
        else:
            recipe["RECIPE_PATH"] = recipe_file

    if recipe and preprocessors:
        steps = []
        for preprocessor_name in preprocessors:
            steps.append({"Processor": preprocessor_name})
        steps.extend(recipe["Process"])
        recipe["Process"] = steps

    if recipe and postprocessors:
        steps = recipe["Process"]
        for postprocessor_name in postprocessors:
            steps.append({"Processor": postprocessor_name})
        recipe["Process"] = steps

    return recipe


def get_recipe_info(recipe_name, override_dirs, recipe_dirs):
    '''Loads a recipe, then prints some information about it. Override aware.'''
    recipe = load_recipe(recipe_name, override_dirs, recipe_dirs)
    if recipe:
        log("Description:         %s" %
            "\n                     "
            .join(recipe.get("Description", "").splitlines()))
        log("Identifier:          %s" % get_identifier(recipe))
        log("Munki import recipe: %s" % has_munkiimporter_step(recipe))
        log("Has check phase:     %s" % has_check_phase(recipe))
        log("Builds package:      %s" % builds_a_package(recipe))
        log("Recipe file path:    %s" % recipe["RECIPE_PATH"])
        if recipe.get("PARENT_RECIPES"):
            log("Parent recipe(s):    %s"
                % "\n                     ".join(recipe["PARENT_RECIPES"]))
        log("Input values: ")
        output = pprint.pformat(recipe.get("Input", {}), indent=4)
        log(" " + output[1:-1])
        return True
    else:
        return False


def git_cmd():
    """Returns a path to a git binary, priority in the order below.
    Returns None if none found.
    1. app pref 'GIT_PATH'
    2. a 'git' binary that can be found in the PATH environment variable
    3. '/usr/bin/git'
    """

    def is_executable(exe_path):
        '''Is exe_path executable?'''
        return os.path.exists(exe_path) and os.access(exe_path, os.X_OK)

    git_path_pref = get_pref("GIT_PATH")
    if git_path_pref:
        if is_executable(git_path_pref):
            # take a GIT_PATH pref
            return git_path_pref
        else:
            log_err("WARNING: Git path given in the 'GIT_PATH' preference:'%s' "
                    "either doesn't exist or is not executable! Falling back "
                    "to one set in PATH, or /usr/bin/git." % git_path_pref)
    for path_env in os.environ["PATH"].split(":"):
        gitbin = os.path.join(path_env, "git")
        if is_executable(gitbin):
            # take the first 'git' in PATH that we find
            return gitbin
    if is_executable("/usr/bin/git"):
        # fall back to /usr/bin/git
        return "/usr/bin/git"
    return None


class GitError(Exception):
    '''Exception to throw if git fails'''
    pass


def run_git(git_options_and_arguments, git_directory=None):
    '''Run a git command and return its output if successful;
       raise GitError if unsuccessful.'''
    gitcmd = git_cmd()
    if not gitcmd:
        raise GitError("ERROR: git is not installed!")
    cmd = [gitcmd]
    cmd.extend(git_options_and_arguments)
    try:
        proc = subprocess.Popen(
            cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
            cwd=git_directory)
        (cmd_out, cmd_err) = proc.communicate()
    except OSError as err:
        raise GitError("ERROR: git execution failed with error code %d: %s"
                       % (err.errno, err.strerror))
    if proc.returncode != 0:
        raise GitError("ERROR: %s" % cmd_err)
    else:
        return cmd_out


def get_recipe_repo(git_path):
    """git clone git_path to local disk and return local path"""

    # figure out a local directory name to clone to
    parts = urlparse(git_path)
    domain_and_port = parts.netloc
    # discard port if any
    domain = domain_and_port.split(':')[0]
    reverse_domain = '.'.join(reversed(domain.split('.')))
    # discard file extension if any
    url_path = os.path.splitext(parts.path)[0]
    dest_name = reverse_domain + url_path.replace('/', '.')
    recipe_repo_dir = (get_pref("RECIPE_REPO_DIR") or
                       "~/Library/AutoPkg/RecipeRepos")
    recipe_repo_dir = os.path.expanduser(recipe_repo_dir)
    dest_dir = os.path.join(recipe_repo_dir, dest_name)
    dest_dir = os.path.abspath(dest_dir)
    gitcmd = git_cmd()
    if not gitcmd:
        log_err("No git binary could be found!")
        return None

    if os.path.exists(dest_dir):
        # probably should attempt a git pull
        # check to see if this is really a git repo first
        if not os.path.isdir(os.path.join(dest_dir, '.git')):
            log_err("%s exists and is not a git repo!" % dest_dir)
            return None
        log("Attempting git pull...")
        try:
            log(run_git(['pull'], git_directory=dest_dir))
            return dest_dir
        except GitError, err:
            log_err(err)
            return None
    else:
        log("Attempting git clone...")
        try:
            log(run_git(['clone', git_path, dest_dir]))
            return dest_dir
        except GitError, err:
            log_err(err)
            return None
    return None


def write_plist_exit_on_fail(plist_dict, path):
    '''Writes a dict to a new plist at path, exits the program
    if the write fails.'''
    try:
        FoundationPlist.writePlist(plist_dict, path)
    except FoundationPlist.NSPropertyListWriteException:
        log_err("Failed to save plist to %s." % path)
        sys.exit(-1)


def print_tool_info(options):
    """Eventually will print some information about the tool
    and environment. For now, just print the current prefs"""
    _ = options
    print "Current preferences:"
    pprint.pprint(get_all_prefs())


def get_repo_info(path_or_url):
    '''Given a path or URL, find a locally installed repo and return
    infomation in a dictionary about it'''
    repo_info = {}
    recipe_repos = get_pref("RECIPE_REPOS") or {}

    parsed = urlparse(path_or_url)
    if parsed.netloc:
        # it's a URL, look it up and find the associated path
        repo_url = path_or_url
        for repo_path in recipe_repos.keys():
            test_url = recipe_repos[repo_path].get("URL")
            if repo_url == test_url:
                # found it; copy the dict info
                repo_info['path'] = repo_path
                repo_info.update(recipe_repos[repo_path])
                # get out now!
                return repo_info
    else:
        repo_path = os.path.abspath(os.path.expanduser(path_or_url))
        if repo_path in recipe_repos:
            repo_info['path'] = repo_path
            repo_info.update(recipe_repos[repo_path])
    return repo_info


def save_pref_or_warn(key, value):
    '''Saves a key and value to preferences, warning if there is an issue'''
    try:
        set_pref(key, value)
    except PreferenceError, err:
        log_err("WARNING: %s" % err)


def get_search_dirs():
    '''Return search dirs from preferences or default list'''
    default = [
        ".",
        "~/Library/AutoPkg/Recipes",
        "/Library/AutoPkg/Recipes"
        ]

    dirs = get_pref("RECIPE_SEARCH_DIRS")
    if isinstance(dirs, basestring):
        # convert a string to a list
        dirs = [dirs]
    return dirs or default


def get_override_dirs():
    '''Return override dirs from preferences or default list'''
    default = [
        "~/Library/AutoPkg/RecipeOverrides"
        ]

    dirs = get_pref("RECIPE_OVERRIDE_DIRS")
    if isinstance(dirs, basestring):
        # convert a string to a list
        dirs = [dirs]
    return dirs or default


def add_search_and_override_dir_options(parser):
    '''Several subcommands use these same options'''
    parser.add_option(
        "-d", "--search-dir", metavar="DIRECTORY", dest="search_dirs",
        action="append", default=[],
        help=("Directory to search for recipes. Can be specified "
              "multiple times."))
    parser.add_option(
        "--override-dir", metavar="DIRECTORY", dest="override_dirs",
        action="append", default=[],
        help=("Directory to search for recipe overrides. Can be "
              "specified multiple times."))


########################
# subcommand functions #
########################

def expand_repo_url(url):
    '''Given a GitHub repo URL-ish name, returns a full GitHub URL. Falls
    back to the 'autopkg' GitHub org, and full non-GitHub URLs return
    unmodified.
    Examples:
    'user/reciperepo'         -> 'https://github.com/user/reciperepo'
    'reciperepo'              -> 'https://github.com/autopkg/reciperepo'
    'http://some/repo/url     -> 'http://some/repo/url'
    '''
    parsed_url = urlparse(url)
    # if no URL scheme was given in the URL, try GitHub URLs
    if not parsed_url.scheme:
        # if URL looks like 'name/repo' then prepend the base GitHub URL
        if re.match(r"\w+/\w+", url):
            url = "https://github.com/%s" % url

        # if URL is one word, assume it's a repo within the 'autopkg' org
        elif re.match(r"\w", url):
            url = "https://github.com/autopkg/%s" % url

    return url


def repo_add(argv):
    '''Add/update one or more repos of recipes'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("""Usage: %s %s recipe_repo_url
Download one or more new recipe repos and add it to the search path.
The 'recipe_repo_url' argument can be of the following forms:
- repo (implies 'https://github.com/autopkg/repo')
- user/repo (implies 'https://github.com/user/repo')
- (http[s]://|git://|user@server:)path/to/any/git/repo

Example: '%s repo-add recipes'
..adds the autopkg/recipes repo from GitHub."""
                     % ("%prog", verb, "%prog"))

    # Parse arguments
    arguments = parser.parse_args(argv[2:])[1]
    if len(arguments) < 1:
        log_err("Need at least one recipe repo URL!")
        return -1

    recipe_search_dirs = get_search_dirs()
    recipe_repos = get_pref("RECIPE_REPOS") or {}
    for repo_url in arguments:
        repo_url = expand_repo_url(repo_url)
        new_recipe_repo_dir = get_recipe_repo(repo_url)
        if new_recipe_repo_dir:
            if not new_recipe_repo_dir in recipe_search_dirs:
                log("Adding %s to RECIPE_SEARCH_DIRS..." % new_recipe_repo_dir)
                recipe_search_dirs.append(new_recipe_repo_dir)
            # add info about this repo to our prefs
            recipe_repos[new_recipe_repo_dir] = {"URL": repo_url}

    # save our updated RECIPE_REPOS and RECIPE_SEARCH_DIRS
    save_pref_or_warn("RECIPE_REPOS", recipe_repos)
    save_pref_or_warn("RECIPE_SEARCH_DIRS", recipe_search_dirs)

    log("Updated search path:")
    for search_dir in get_pref("RECIPE_SEARCH_DIRS"):
        log("  '%s'" % search_dir)


def repo_delete(argv):
    '''Delete a recipe repo'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage(
        "Usage: %s %s recipe_repo_path_or_url [...]\n"
        "Delete one or more recipe repo and remove it from the search "
        "path." % ("%prog", verb))

    # Parse arguments
    arguments = parser.parse_args(argv[2:])[1]
    if len(arguments) < 1:
        log_err("Need at least one recipe repo path or URL!")
        return -1

    recipe_repos = get_pref("RECIPE_REPOS") or {}
    recipe_search_dirs = get_search_dirs()

    for path_or_url in arguments:
        path_or_url = expand_repo_url(path_or_url)
        repo_path = get_repo_info(path_or_url).get('path')
        if not repo_path:
            log_err("ERROR: Can't find an installed repo for %s" % path_or_url)
            continue

        # first, remove from RECIPE_SEARCH_DIRS
        if repo_path in recipe_search_dirs:
            recipe_search_dirs.remove(repo_path)
        # now remove the repo files
        try:
            shutil.rmtree(repo_path)
        except (OSError, IOError), err:
            log_err("ERROR: Could not remove %s: %s" % (repo_path, err))
        else:
            # last, remove from RECIPE_REPOS
            del recipe_repos[repo_path]

    # save our updated RECIPE_REPOS and RECIPE_SEARCH_DIRS
    save_pref_or_warn("RECIPE_REPOS", recipe_repos)
    save_pref_or_warn("RECIPE_SEARCH_DIRS", recipe_search_dirs)


def repo_list(argv):
    '''List recipe repos'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s\n"
                     "List all installed recipe repos." % ("%prog", verb))

    recipe_repos = get_pref("RECIPE_REPOS") or {}
    if recipe_repos:
        for key in sorted(recipe_repos.keys()):
            print "%s (%s)" % (key, recipe_repos[key]['URL'])
        print
    else:
        print "No recipe repos."


def repo_update(argv):
    '''Update one or more recipe repos'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s recipe_repo_path_or_url [...]\n"
                     "Update one or more recipe repos.\n"
                     "You may also use 'all' to update all installed recipe "
                     "repos." % ("%prog", verb))

    # Parse arguments
    arguments = parser.parse_args(argv[2:])[1]
    if len(arguments) < 1:
        log_err("Need at least one recipe repo path or URL!")
        return -1

    if 'all' in arguments:
        # just get all repos
        recipe_repos = get_pref("RECIPE_REPOS") or {}
        repo_dirs = [key for key in recipe_repos.keys()]
    else:
        repo_dirs = []
        for path_or_url in arguments:
            path_or_url = expand_repo_url(path_or_url)
            repo_path = get_repo_info(path_or_url).get('path')
            if not repo_path:
                log_err("ERROR: Can't find an installed repo for %s"
                        % path_or_url)
            else:
                repo_dirs.append(repo_path)

    for repo_dir in repo_dirs:
        log("Attempting git pull for %s..." % repo_dir)
        try:
            log(run_git(['pull'], git_directory=repo_dir))
        except GitError, err:
            log_err(err)


def do_gh_code_search(query, use_token=False):
    '''Search GitHub code repos'''
    gh_session = autopkglib.github.GitHubSession()
    if use_token:
        gh_session.setup_token()
    # Do the search, including text match metadata
    (results, code) = gh_session.call_api(
        "/search/code",
        query=query,
        accept="application/vnd.github.v3.text-match+json")
    if code == 403:
        log_err(
            "You've probably hit the GitHub's search rate limit, officially 5 "
            "requests per minute. Server response follows:\n")
        log_err(results["message"])
        log_err(results["documentation_url"])
        return None
    if results == None or code == None:
        log_err("A GitHub API error occurred!")
        return None
    return results


def print_gh_search_results(results_items):
    '''Pretty print our GitHub search results'''
    column_spacer = 4
    max_name_length = (
        max([len(r["name"]) for r in results_items]) + column_spacer)
    max_repo_length = (
        max([len(r["repository"]["name"]) for r in results_items])
        + column_spacer)
    spacers = (max_name_length, max_repo_length)

    print
    format_str = "%-{0}s %-{1}s %-40s".format(*spacers)
    print format_str % ("Name", "Repo", "Path")
    print format_str % ("----", "----", "----")
    results_items.sort(key=operator.itemgetter("repository"))
    for result in results_items:
        repo = result["repository"]
        name, path = result["name"], result["path"]
        if repo["full_name"].startswith("autopkg"):
            repo_name = repo["name"]
        else:
            repo_name = repo["full_name"]
        print format_str % (name, repo_name, path)


def search_recipes(argv):
    '''Search recipes on GitHub'''
    default_user = "autopkg"
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s [options] search_term\n"
                     "Search for recipes on GitHub. The AutoPkg organization "
                     "at github.com/autopkg\n"
                     "is the canonical 'repository' of recipe repos, "
                     "which is what is searched by\n"
                     "default."
                     % ("%prog", verb))
    parser.add_option("-u", "--user", default=default_user,
                      help=("Alternate GitHub user whose repos to search. "
                            "Defaults to '%s'." % default_user))
    parser.add_option("-p", "--path-only", action="store_true", default=False,
                      help=("Restrict search results to the recipe's path "
                            "only. Note that the search API currently does not "
                            "support fuzzy matches, so only exact directory or "
                            "filenames (minus the extensions) will be "
                            "returned."))
    parser.add_option("-t", "--use-token", action="store_true", default=False,
                      help=("Use a public-scope GitHub token for a higher "
                            "rate limit. If a token doesn't exist, you'll "
                            "be prompted for your account credentials to "
                            "create one."))

    # Parse arguments
    options, arguments = parser.parse_args(argv[2:])
    if len(arguments) < 1:
        log_err("No search query specified!")
        return -1

    results_limit = 100
    term = arguments[0]
    query = "q=%s+extension:recipe+user:%s" % (term, options.user)
    qualifier_in = "in:path,file"
    if options.path_only:
        qualifier_in += "path"
    query += "+" + qualifier_in
    query += "&per_page=%s" % results_limit

    results = do_gh_code_search(query, use_token=options.use_token)
    if not results:
        return

    count = results["total_count"]
    if count == 0:
        print "No results."
        return

    print_gh_search_results(results["items"])

    print
    print "To add a new recipe repo, use 'autopkg repo-add <repo name>'"

    if count > results_limit:
        print
        print ("Warning: Search yielded more than 100 results. Please try a "
               "more specific search term.")


def display_help(argv, subcommands):
    '''Display top-level help'''
    main_command_name = os.path.basename(argv[0])
    print ("Usage: %s <verb> <options>, where <verb> is one of the following:"
           % main_command_name)
    print
    # find length of longest subcommand
    max_key_len = max([len(key) for key in subcommands.keys()])
    for key in sorted(subcommands.keys()):
        # pad name of subcommand to make pretty columns
        subcommand = key + (" " * (max_key_len - len(key)))
        print "    %s  (%s)" % (subcommand, subcommands[key]['help'])
    print
    print "%s <verb> --help for more help for that verb" % main_command_name


def get_info(argv):
    '''Display info about configuration or a recipe'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s [options] [recipe]" % ("%prog", verb))

    # Parse arguments
    add_search_and_override_dir_options(parser)
    options, arguments = parser.parse_args(argv[2:])

    override_dirs = options.override_dirs or get_override_dirs()
    search_dirs = options.search_dirs or get_search_dirs()

    if len(arguments) == 0:
        # return configuration info
        print_tool_info(options)
        return 0
    elif len(arguments) == 1:
        if get_recipe_info(arguments[0], override_dirs, search_dirs):
            return 0
        else:
            #log_err("Can't find recipe %s, or it is invalid." % arguments[0])
            return -1
    else:
        log_err("Too many recipes!")
        return -1


def processor_info(argv):
    '''Display info about a processor'''

    def print_vars(var_dict, indent=0):
        """Print a dict of dicts and strings"""
        for key, value in var_dict.items():
            if isinstance(value, dict):
                print " " * indent, "%s:" % key
                print_vars(value, indent=indent + 2)
            else:
                print " " * indent, "%s: %s" % (key, value)

    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s [options] processorname" % ("%prog", verb))
    parser.add_option("-r", "--recipe", metavar="RECIPE",
                      help="Name of recipe using the processor.")

    # Parse arguments
    add_search_and_override_dir_options(parser)
    options, arguments = parser.parse_args(argv[2:])

    override_dirs = options.override_dirs or get_override_dirs()
    search_dirs = options.search_dirs or get_search_dirs()

    if len(arguments) != 1:
        log_err("Need exactly one processor name")
        return -1

    processor_name = arguments[0]

    recipe = None
    if options.recipe:
        recipe = load_recipe(options.recipe, override_dirs, search_dirs)

    try:
        processor_class = get_processor(processor_name, recipe=recipe)
    except (KeyError, AttributeError):
        log_err("Unknown processor '%s'" % processor_name)
        return -1

    try:
        description = processor_class.description
    except AttributeError:
        try:
            description = processor_class.__doc__
        except AttributeError:
            description = ""
    try:
        input_vars = processor_class.input_variables
    except AttributeError:
        input_vars = {}
    try:
        output_vars = processor_class.output_variables
    except AttributeError:
        output_vars = {}

    print "Description: %s" % description
    print "Input variables:"
    print_vars(input_vars, indent=2)
    print "Output variables:"
    print_vars(output_vars, indent=2)


def list_processors(argv):
    '''List the processors in autopkglib'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s [options]\n"
                     "List the core Processors." % ("%prog", verb))

    print "\n".join(sorted(processor_names()))


def make_suggestions_for(search_name):
    '''Suggest existing recipes with names similar to search name.'''
    # trim '.recipe' from the end if it exists
    if os.path.splitext(search_name)[1].lower() == '.recipe':
        search_name = os.path.splitext(search_name)[0]
    (search_name_base, search_name_ext) = os.path.splitext(search_name.lower())
    recipe_names = [os.path.splitext(item['Name'])
                    for item in get_recipe_list()]
    recipe_names = list(set(recipe_names))

    matches = []
    if len(search_name_base) > 3:
        matches = [''.join(item) for item in recipe_names
                   if (search_name_base in item[0].lower()
                       and search_name_ext in item[1].lower())]
    if search_name_ext:
        compare_names = [item[0].lower() for item in recipe_names
                         if item[1].lower() == search_name_ext]
    else:
        compare_names = [item[0].lower() for item in recipe_names]

    close_matches = difflib.get_close_matches(
        search_name_base, compare_names)
    if close_matches:
        matches.extend(
            [''.join(item) for item in recipe_names
             if (''.join(item) not in matches
                 and item[0].lower() in close_matches)])
        if search_name_ext:
            matches = [item for item in matches
                       if os.path.splitext(item)[1] == search_name_ext]

    if len(matches) == 1:
        print "Maybe you meant %s?" % matches[0]
    elif len(matches):
        print "Maybe you meant one of: %s?" % ', '.join(matches)


def get_recipe_list(override_dirs=None, search_dirs=None,
                    augmented_list=False, show_all=False):
    '''Factor out the core of list_recipes for use in other functions'''
    override_dirs = override_dirs or get_override_dirs()
    search_dirs = search_dirs or get_search_dirs()

    recipes = []
    for directory in search_dirs:
        normalized_dir = os.path.abspath(os.path.expanduser(directory))
        if not os.path.isdir(normalized_dir):
            continue

        # find all top-level recipes and recipes one level down
        matches = (
            glob.glob(os.path.join(normalized_dir, "*.recipe")) +
            glob.glob(os.path.join(normalized_dir, "*/*.recipe")))

        for match in matches:
            recipe = recipe_plist_from_file(match)
            if valid_recipe_plist(recipe):
                recipe_name = os.path.basename(match)

                recipe["Name"] = os.path.splitext(recipe_name)[0]
                recipe["Path"] = match

                # If a top level "Identifier" key is not discovered,
                # this will copy an IDENTIFIER key in the "Input"
                # entry to the top level of the recipe dictionary.
                if not "Identifier" in recipe:
                    identifier = get_identifier(recipe)
                    if identifier:
                        recipe["Identifier"] = identifier

                recipes.append(recipe)

    for directory in override_dirs:
        normalized_dir = os.path.abspath(os.path.expanduser(directory))
        if not os.path.isdir(normalized_dir):
            continue
        for filename in os.listdir(normalized_dir):
            if filename.endswith(".recipe"):
                pathname = os.path.join(normalized_dir, filename)
                override = recipe_plist_from_file(pathname)

                if valid_override_plist(override):
                    # override points to a valid recipe
                    override_name = os.path.basename(pathname)
                    override["Name"] = os.path.splitext(override_name)[0]
                    override["Path"] = pathname
                    override["IsOverride"] = True

                    if augmented_list and not show_all:
                        # If an override has the same Name as the ParentRecipe
                        # AND the override's ParentRecipe matches said
                        # recipe's Identifier, remove the ParentRecipe from the
                        # listing.
                        for recipe in recipes:
                            if recipe["Name"] == override["Name"] and \
                               recipe.get("Identifier") == \
                               override.get("ParentRecipe"):
                                recipes.remove(recipe)

                    recipes.append(override)
    return recipes


def list_recipes(argv):
    '''List all available recipes'''
    verb = argv[1]
    parser = optparse.OptionParser()
    parser.set_usage("Usage: %s %s [options]\n"
                     "List all the recipes this tool can find automatically.\n"
                     % ("%prog", verb))
    parser.add_option("-i", "--with-identifiers", action="store_true",
                      help="Include recipe's identifier in the list.")
    parser.add_option("-p", "--with-paths", action="store_true",
                      help="Include recipe's path in the list.")
    parser.add_option("--plist", action="store_true",
                      help=("Print recipe list in plist format. This provides "
                            "all available key/value pairs of recipes."))
    parser.add_option("-a", "--show-all", action="store_true",
                      help=("Include recipes with duplicate shortnames, "
                            "including overrides. Use in conjunction with "
                            "'--with-identifiers', '--with-paths', or "
                            "'--plist'."))

    # Parse options
    add_search_and_override_dir_options(parser)
    options = parser.parse_args(argv[1:])[0]

    augmented_list = False
    if options.with_identifiers or \
       options.with_paths or \
       options.plist:
        augmented_list = True

    if options.show_all and not augmented_list:
        log_err("The '--show-all' option is only valid when used with "
                "'--with-paths', '--with-identifiers', or '--plist' options.")
        return -1
    elif options.plist and (options.with_identifiers or options.with_paths):
        log_err("It is invalid to specify '--with-identifiers' or "
                "'--with-paths' with '--plist'.")
        return -1

    override_dirs = options.override_dirs or get_override_dirs()
    search_dirs = options.search_dirs or get_search_dirs()

    recipes = get_recipe_list(override_dirs=override_dirs,
                              search_dirs=search_dirs,
                              augmented_list=augmented_list,
                              show_all=options.show_all)

    lowercase_sorted = sorted(recipes, key=lambda s: s["Name"].lower())

    if options.plist:
        print FoundationPlist.writePlistToString(lowercase_sorted)
    else:
        column_spacer = 1
        max_name_length = 0
        max_identifier_length = 0

        if recipes and augmented_list:
            max_name_length = (
                max([len(r["Name"]) for r in recipes]) + column_spacer)

            max_identifier_length = (
                max([len(r.get("Identifier", "")) for r in recipes])
                + column_spacer) if options.with_identifiers else column_spacer

            spacers = (max_name_length, max_identifier_length)
            format_str = "%-{0}s %-{1}s %-20s".format(*spacers)
        else:
            format_str = "%s%s%s"

        output = []

        for recipe in lowercase_sorted:
            name = recipe["Name"]

            # Only display identifier string if enabled
            identifier = ""
            if options.with_identifiers:
                identifier = recipe.get("Identifier", "")

            # To make display cleaner, switch out a ~ for the user's home.
            recipe_path = ""
            if options.with_paths and "Path" in recipe:
                user_home = os.environ.get("HOME")
                recipe_path = recipe["Path"]
                if user_home:
                    recipe_path = recipe_path.replace(user_home, "~")

            out_string = (format_str % (name, identifier, recipe_path))

            if not out_string in output:
                output.append(out_string)

        print "\n".join(output)


def make_override(argv):
    '''Make a recipe override skeleton.'''
    verb = argv[1]
    parser = optparse.OptionParser()

    parser.set_usage("Usage: %s %s [options] [recipe]\n"
                     "Create a skeleton override file for a recipe. It will "
                     "be stored in the first default override directory "
                     "or that given by '--override-dir'" % ("%prog", verb))

    # Parse arguments
    add_search_and_override_dir_options(parser)
    parser.add_option("-n", "--name", metavar="FILENAME",
                      help="Name for override file.")
    options, arguments = parser.parse_args(argv[2:])

    override_dirs = options.override_dirs or get_override_dirs()
    search_dirs = options.search_dirs or get_search_dirs()

    if len(arguments) != 1:
        log_err("Need exactly one recipe to override!")
        return -1

    recipe_name = arguments[0]
    if os.path.isfile(recipe_name):
        log_err("%s doesn't work with absolute recipe paths, "
                "as it may not be able to correctly determine the value "
                "for 'name' that would be searched in recipe directories."
                % verb)
        return -1

    recipe = load_recipe(recipe_name,
                         override_dirs=[],
                         recipe_dirs=search_dirs)
    if not recipe:
        log_err("No valid recipe found for %s" % recipe_name)
        log_err("Dir(s) searched:\n\t%s" % "\n\t".join(search_dirs))
        return 1

    # make sure parent has an identifier
    parent_identifier = get_identifier(recipe)
    if not parent_identifier:
        log_err("%s is missing an Identifier. Cannot make an override."
                % recipe.get("RECIPE_PATH", recipe_name))
        return 1

    override_name = (
        options.name
        or os.path.splitext(os.path.basename(recipe["RECIPE_PATH"]))[0])

    reversed_name = '.'.join(reversed(override_name.split('.')))
    override_identifier = "local." + reversed_name

    override_plist = {"Identifier": override_identifier,
                      "Input": recipe["Input"],
                      "ParentRecipe": parent_identifier
                     }

    if "IDENTIFIER" in override_plist["Input"]:
        del override_plist["Input"]["IDENTIFIER"]

    override_dir = os.path.expanduser(override_dirs[0])
    if not os.path.exists(os.path.join(override_dir)):
        try:
            os.makedirs(os.path.join(override_dir))
        except (OSError, IOError), err:
            log_err("Could not create %s: %s" % (override_dir, err))
            return -1

    override_name = (
        options.name
        or os.path.splitext(os.path.basename(recipe["RECIPE_PATH"]))[0])

    override_file = os.path.join(override_dir, "%s.recipe" % override_name)
    if os.path.exists(override_file):
        log_err("An override plist already exists at %s, "
                "will not overwrite it." % override_file)
        return -1
    else:
        FoundationPlist.writePlist(override_plist, override_file)
        log("Override file saved to %s" % override_file)
        return 0


def run_recipes(argv):
    """Run one or more recipes. If called with 'install' verb, run .install
       recipe"""
    verb = argv[1]
    parser = optparse.OptionParser()
    if verb == 'install':
        parser.set_usage("Usage: %s %s [options] [itemname ...]\n"
                         "Install one or more items." % ("%prog", verb))
    else:
        parser.set_usage("Usage: %s %s [options] [recipe ...]\n"
                         "Run one or more recipes." % ("%prog", verb))

    # Parse arguments.
    parser.add_option('--pre', '--preprocessor', action='append',
                      dest='preprocessors', default=[], metavar="PREPROCESSOR",
                      help=("Name of a processor to run before each recipe. "
                            "Can be repeated to run multiple preprocessors."))
    parser.add_option('--post', '--postprocessor', action='append',
                      dest='postprocessors', default=[],
                      metavar="POSTPROCESSOR",
                      help=("Name of a processor to run after each recipe. "
                            "Can be repeated to run multiple postprocessors."))
    parser.add_option("-c", "--check", action="store_true",
                      help="Only check for new/changed downloads.")
    parser.add_option("-k", "--key", action="append", dest="variables",
                      default=[], metavar="KEY=VALUE",
                      help=("Provide key/value pairs for recipe input. "
                            "Caution: values specified here will be applied "
                            "to all recipes."))
    parser.add_option("-l", "--recipe-list", metavar="TEXT_FILE",
                      help="Path to a text file with a list of recipes to run.")
    parser.add_option("-p", "--pkg", metavar="PKG_OR_DMG",
                      help=("Path to a pkg or dmg to provide to a recipe. "
                            "Downloading will be skipped."))
    parser.add_option("--report-plist", metavar="OUTPUT_PATH",
                      help=("File path to save run report plist."))
    parser.add_option("-v", "--verbose", action="count", default=0,
                      help="Verbose output.")
    add_search_and_override_dir_options(parser)
    options, arguments = parser.parse_args(argv[2:])

    override_dirs = options.override_dirs or get_override_dirs()
    search_dirs = options.search_dirs or get_search_dirs()

    # initialize some variables
    summary_results = {}
    failures = []
    error_count = 0

    # Add variables from environment
    cli_values = {}
    for key, value in os.environ.items():
        if key.startswith("AUTOPKG_"):
            if options.verbose > 1:
                log("Using environment var %s=%s" % (key, value))
            local_key = key[8:]
            cli_values[local_key] = value

    # Add variables from commandline. These might override those from
    # environment variables
    for arg in options.variables:
        (key, sep, value) = arg.partition("=")
        if sep != "=":
            log_err("Invalid variable [key=value]: %s" % arg)
            log_err(parser.get_usage())
            return 1
        cli_values[key] = value

    if options.pkg:
        cli_values["PKG"] = options.pkg

    recipe_paths = []
    if verb == 'install':
        # hold on for syntactic sugar!
        for index, item in enumerate(arguments):
            # if recipe doesn't have an extension, append '.install'
            if not os.path.splitext(item)[1]:
                # no extension!
                arguments[index] = item + '.install'
            elif os.path.splitext(item)[1] != '.install':
                log_err("Can't install with a non-install recipe: %s" % item)
                del arguments[index]

    recipe_paths.extend(arguments)
    if options.recipe_list:
        with open(options.recipe_list, "r") as file_desc:
            data = file_desc.read()
        recipes = [line for line in data.splitlines()
                   if line and not line.startswith("#")]
        recipe_paths.extend(recipes)

    if not recipe_paths:
        log_err(parser.get_usage())
        return -1

    if len(recipe_paths) > 1 and options.pkg:
        log_err("-p/--pkg option can't be used with multiple recipes!")
        return -1

    cache_dir = get_pref("CACHE_DIR") or "~/Library/AutoPkg/Cache"
    cache_dir = os.path.expanduser(cache_dir)
    if not os.path.exists(cache_dir):
        os.makedirs(cache_dir, 0755)
    current_run_results_plist = os.path.join(cache_dir, "autopkg_results.plist")

    run_results = []
    try:
        FoundationPlist.writePlist(run_results, current_run_results_plist)
    except IOError as err:
        log_err(
            "Can't write results to %s: %s"
            % (current_run_results_plist, err.strerror))

    if options.report_plist:
        results_report = dict()
        write_plist_exit_on_fail(results_report, options.report_plist)

    make_suggestions = True
    if len(recipe_paths) > 1:
        # don't make suggestions or offer to search GitHub
        # if we have a list of recipes
        make_suggestions = False

    for recipe_path in recipe_paths:
        recipe = load_recipe(recipe_path,
                             override_dirs,
                             search_dirs,
                             options.preprocessors,
                             options.postprocessors,
                             make_suggestions=make_suggestions,
                             search_github=make_suggestions)
        if not recipe:
            if not make_suggestions:
                log_err("No valid recipe found for %s" % recipe_path)
            error_count += 1
            continue

        if options.check:
            # remove steps from the end of the recipe Process until we find a
            # EndOfCheckPhase step
            while len(recipe["Process"]) >= 1 and \
                    recipe["Process"][-1]["Processor"] != "EndOfCheckPhase":
                del recipe["Process"][-1]
            if len(recipe["Process"]) == 0:
                log_err("Recipe at %s is missing EndOfCheckPhase Processor, "
                        "not possible to perform check." % recipe_path)
                error_count += 1
                continue

        log("Processing %s..." % recipe_path)
        # Obtain prefs from the defaults domain
        prefs = get_all_prefs()
        # Add RECIPE_PATH and RECIPE_DIR variables for use by processors
        prefs["RECIPE_PATH"] = os.path.abspath(recipe["RECIPE_PATH"])
        prefs["RECIPE_DIR"] = os.path.dirname(prefs["RECIPE_PATH"])
        prefs["PARENT_RECIPES"] = recipe.get("PARENT_RECIPES", [])
        # Update search locations that may have been overridden with CLI or
        # environment variables
        prefs["RECIPE_SEARCH_DIRS"] = search_dirs
        prefs["RECIPE_OVERRIDE_DIRS"] = override_dirs

        # Add our verbosity level
        prefs["verbose"] = options.verbose

        autopackager = AutoPackager(options, prefs)

        try:
            autopackager.process_cli_overrides(recipe, cli_values)
            autopackager.verify(recipe)
            autopackager.process(recipe)
        except AutoPackagerError as err:
            error_count += 1
            failure = {}
            log_err("Failed.")
            failure["recipe"] = recipe_path
            failure["message"] = unicode(err)
            failure["traceback"] = traceback.format_exc()
            failures.append(failure)
            autopackager.results.append({'RecipeError': unicode(err).rstrip()})

        run_results.append(autopackager.results)
        try:
            FoundationPlist.writePlist(run_results, current_run_results_plist)
        except IOError as err:
            log_err(
                "Can't write results to %s: %s"
                % (current_run_results_plist, err.strerror))

        # build a pathname for a receipt
        recipe_basename = os.path.splitext(os.path.basename(recipe_path))[0]
        # TO-DO: if recipe processing fails too early,
        # autopackager.env["RECIPE_CACHE_DIR"] is not defined and we can't
        # write a recipt. We should handle this better.
        # for now, just write the receipt to /tmp/receipts
        receipt_dir = os.path.join(
            autopackager.env.get("RECIPE_CACHE_DIR", "/tmp"), "receipts")
        timestamp = time.strftime("%Y%m%d-%H%M%S")
        receipt_name = "%s-receipt-%s.plist" % (recipe_basename, timestamp)

        if not os.path.exists(receipt_dir):
            try:
                os.makedirs(receipt_dir)
            except OSError, err:
                log_err("Can't create %s: %s" % (receipt_dir, err.strerror))

        # look through results for interesting info
        # and record for later summary and use
        for item in autopackager.results:
            if item.get("Output"):
                # record any summary results
                output_keys = item["Output"].keys()
                results_keys = [key for key in output_keys
                                if key.endswith('_summary_result')]
                for key in results_keys:
                    result = item["Output"][key]
                    summary_text = result.get('summary_text', '')
                    data = result.get('data')
                    if key not in summary_results:
                        summary_results[key] = {}
                        summary_results[key]['summary_text'] = summary_text
                        if type(data).__name__ in ['dict', '__NSCFDictionary']:
                            summary_results[key]['header'] = (
                                result.get('report_fields') or data.keys())
                        summary_results[key]['data_rows'] = []
                    summary_results[key]['data_rows'].append(data)

        # save receipt
        if os.path.exists(receipt_dir):
            receipt_path = os.path.join(receipt_dir, receipt_name)
            try:
                FoundationPlist.writePlist(autopackager.results, receipt_path)
                if options.verbose:
                    log("Receipt written to %s" % receipt_path)
            except IOError as err:
                log_err("Can't write receipt to %s: %s"
                        % (receipt_path, err.strerror))

    # done running recipes, print a summary
    if failures:
        log("\nThe following recipes failed:")
        for item in failures:
            log("    %s" % item["recipe"])
            log("        %s" % item["message"])

    if summary_results:
        for key, value in summary_results.items():
            log("\n%s" % value['summary_text'])

            # make our table header
            display_header = [item.replace('_', ' ').title()
                              for item in value['header']]
            underlines = ['-'*len(item) for item in value['header']]
            rows = [display_header, underlines]
            for row in value['data_rows']:
                this_row = []
                for field in value['header']:
                    this_row.append(row[field])
                rows.append(this_row)

            # calculate the widths of each column
            widths = []
            for column in range(len(value['header'])):
                this_column = [len(row[column]) for row in rows]
                widths.append(max(this_column) + 2)

            # build a format string for each row based on our
            # column widths
            format_str = '    '
            for count, width in enumerate(widths):
                # adding format strings with 'count' for 2.6 compatibility
                format_str += "{" + str(count) + ":<" + str(width) + "}"

            # print each row (which includes the header rows)
            for row in rows:
                log(format_str.format(*row))

    if not summary_results:
        log("\nNothing downloaded, packaged or imported.")

    # save report plist with the summary data
    if options.report_plist:
        results_report['failures'] = failures
        results_report['summary_results'] = summary_results
        write_plist_exit_on_fail(results_report, options.report_plist)
        log("\nReport plist saved to %s." % options.report_plist)

    return error_count


def main(argv):
    """Main routine"""
    # define our subcommands ('verbs')
    subcommands = {
        "help": {
            "function": display_help,
            "help": "Display this help"},
        "info": {
            "function": get_info,
            "help": "Get info about configuration or a recipe"},
        "install": {
            "function": run_recipes,
            "help": ("Run one or more install recipes. "
                     "Example: autopkg install Firefox -- "
                     "equivalent to: autopkg run Firefox.install")},
        "list-recipes": {
            "function": list_recipes,
            "help": "List recipes available locally"},
        "list-processors": {
            "function": list_processors,
            "help": "List available core Processors"},
        "make-override": {
            "function": make_override,
            "help": "Make a recipe override"},
        "processor-info": {
            "function": processor_info,
            "help": "Get information about a specific processor"},
        "repo-add": {
            "function": repo_add,
            "help": "Add one or more recipe repo from a URL"},
        "repo-delete": {
            "function": repo_delete,
            "help": "Delete a recipe repo"},
        "repo-list": {
            "function": repo_list,
            "help": "List installed recipe repos"},
        "repo-update": {
            "function": repo_update,
            "help": "Update one or more recipe repos"},
        "run": {
            "function": run_recipes,
            "help": "Run one or more recipes"},
        "search": {
            "function": search_recipes,
            "help": "Search for recipes on GitHub."},
        "version": {
            "function": print_version,
            "help": "Print the current version of autopkg"}
    }

    # Warn against running as root
    if os.getuid() == 0:
        log_err("\n" + "WARNING! " * 8 + "\n")
        log_err(
            "    Running AutoPkg as root or using `sudo` is not recommended!\n"
            "    A mistake in a recipe or processor could modify or delete\n"
            "    important system files.\n"
            "    Please run autopkg as an unprivileged user.\n"
            "    A future release of autopkg may fail with an error if run as\n"
            "    root.\n")
        log_err("WARNING! " * 8 + "\n")

    try:
        verb = argv[1]
    except IndexError:
        verb = 'help'
    if verb.startswith("-"):
        # option instead of a verb
        verb = 'help'
    if verb == 'help' or not verb in subcommands.keys():
        display_help(argv, subcommands)
    else:
        # call the function and pass it the argument list
        # we leave the verb in the list in case one function can handle
        # multiple verbs
        exit(subcommands[verb]['function'](argv))

    exit()


if __name__ == "__main__":
    sys.exit(main(sys.argv))
